Hanye官网
Vous ne pouvez pas sélectionner plus de 25 sujets Les noms de sujets doivent commencer par une lettre ou un nombre, peuvent contenir des tirets ('-') et peuvent comporter jusqu'à 35 caractères.

[id].vue 20KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653
  1. <template>
  2. <div>
  3. <div class="w-full h-[55px] sm:h-[72px]"></div>
  4. <ErrorBoundary :error="error">
  5. <div v-if="isLoading" class="flex justify-center py-12">
  6. <!-- 加载中 -->
  7. <div
  8. class="animate-spin h-8 w-8 border-4 border-blue-500 rounded-full border-t-transparent"
  9. ></div>
  10. </div>
  11. <div v-else>
  12. <!-- 面包屑导航 -->
  13. <div class="max-w-full mb-6 xl:px-2 lg:px-2 md:px-4 px-4 mt-6">
  14. <div class="max-w-screen-2xl mx-auto">
  15. <nuxt-link
  16. to="/"
  17. class="justify-start text-white/60 text-base font-normal"
  18. >ホーム</nuxt-link
  19. >
  20. <span class="text-white/60 text-base font-normal px-2"> / </span>
  21. <nuxt-link
  22. to="/products"
  23. class="text-white/60 text-base font-normal"
  24. >製品一覧</nuxt-link
  25. >
  26. <span class="text-white/60 text-base font-normal px-2"> / </span>
  27. <nuxt-link
  28. v-if="product?.category"
  29. :to="`/products?category=${encodeURIComponent(product.category)}`"
  30. class="text-white/60 text-base font-normal"
  31. >{{ product.category }}</nuxt-link
  32. >
  33. <span class="text-white/60 text-base font-normal px-2"> / </span>
  34. <span class="text-white text-base font-normal">{{
  35. product?.title || product?.name
  36. }}</span>
  37. </div>
  38. </div>
  39. <!-- 产品详情内容 -->
  40. <div
  41. v-if="product"
  42. class="max-w-full mb-12 md:mb-20 lg:mb-32 xl:px-2 lg:px-2 md:px-4 px-4"
  43. >
  44. <div class="max-w-screen-2xl mx-auto">
  45. <div class="grid grid-cols-1 lg:grid-cols-2 gap-8 lg:gap-16">
  46. <!-- 左侧产品图片 -->
  47. <div class="flex flex-col gap-6">
  48. <!-- 主图展示 -->
  49. <div
  50. class="bg-zinc-900 rounded-lg p-8 relative overflow-hidden group aspect-square"
  51. >
  52. <!-- 加载状态 -->
  53. <div
  54. v-if="isImageLoading"
  55. class="absolute inset-0 flex items-center justify-center bg-zinc-800/50 z-10"
  56. >
  57. <div
  58. class="animate-spin h-8 w-8 border-4 border-blue-500 rounded-full border-t-transparent"
  59. ></div>
  60. </div>
  61. <!-- 主图容器 -->
  62. <div class="relative w-full h-full">
  63. <!-- 当前图片 -->
  64. <img
  65. :src="currentImage"
  66. :alt="product.name"
  67. class="absolute inset-0 w-full h-full object-contain rounded-lg transition-all duration-500"
  68. :class="{
  69. 'opacity-0': isImageLoading,
  70. 'opacity-100': !isImageLoading,
  71. }"
  72. @load="handleImageLoad"
  73. @error="handleImageError"
  74. />
  75. <!-- 预加载图片 -->
  76. <img
  77. v-if="preloadImage"
  78. :src="preloadImage"
  79. class="absolute inset-0 w-full h-full object-contain rounded-lg opacity-0"
  80. @load="handlePreloadComplete"
  81. />
  82. </div>
  83. <!-- 错误提示 -->
  84. <div
  85. v-if="imageError"
  86. class="absolute inset-0 flex items-center justify-center bg-red-900/50 z-20"
  87. >
  88. <div class="flex flex-col items-center gap-2">
  89. <span class="text-white"
  90. >画像の読み込みに失敗しました</span
  91. >
  92. <button
  93. @click.stop="retryLoadImage"
  94. class="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors duration-300"
  95. >
  96. 再試行
  97. </button>
  98. </div>
  99. </div>
  100. </div>
  101. <!-- 缩略图列表 -->
  102. <div class="flex gap-4 overflow-x-auto pb-2 scrollbar-hide">
  103. <div
  104. v-for="(image, index) in [
  105. product.image,
  106. ...(product.gallery || []),
  107. ]"
  108. :key="index"
  109. @click="changeImage(image)"
  110. class="flex-shrink-0 w-20 h-20 cursor-pointer rounded-lg transition-all duration-300 relative group aspect-square p-0.5"
  111. :class="{
  112. 'bg-gradient-to-r from-blue-500 to-blue-600':
  113. currentImage === image,
  114. 'hover:bg-gradient-to-r hover:from-blue-500/50 hover:to-blue-600/50':
  115. currentImage !== image,
  116. 'opacity-50':
  117. isThumbnailLoading[index] || thumbnailErrors[index],
  118. }"
  119. >
  120. <!-- 缩略图加载状态 -->
  121. <div
  122. v-if="isThumbnailLoading[index]"
  123. class="absolute inset-0 flex items-center justify-center bg-zinc-800 rounded-lg"
  124. >
  125. <div
  126. class="animate-spin h-4 w-4 border-2 border-blue-500 rounded-full border-t-transparent"
  127. ></div>
  128. </div>
  129. <!-- 缩略图遮罩 -->
  130. <div
  131. class="absolute inset-0 bg-black/0 transition-all duration-300 rounded-lg"
  132. :class="{
  133. 'bg-black/30': currentImage === image,
  134. 'group-hover:bg-black/20': currentImage !== image,
  135. }"
  136. ></div>
  137. <img
  138. :src="image"
  139. :alt="`${product.name} - 画像 ${index + 1}`"
  140. class="w-full h-full object-cover transition-all duration-300 rounded-lg"
  141. :class="{
  142. 'opacity-0': isThumbnailLoading[index],
  143. 'opacity-100': !isThumbnailLoading[index],
  144. 'group-hover:scale-110': currentImage !== image,
  145. }"
  146. @load="handleThumbnailLoad(index)"
  147. @error="handleThumbnailError(index)"
  148. />
  149. <!-- 选中标记 -->
  150. <div
  151. v-if="currentImage === image"
  152. class="absolute top-1 right-1 w-4 h-4 bg-blue-500 rounded-full flex items-center justify-center"
  153. >
  154. <div class="w-2 h-2 bg-white rounded-full"></div>
  155. </div>
  156. <!-- 缩略图错误提示 -->
  157. <div
  158. v-if="thumbnailErrors[index]"
  159. class="absolute inset-0 flex items-center justify-center bg-red-900/50 rounded-lg"
  160. >
  161. <div class="flex flex-col items-center gap-1">
  162. <span class="text-white text-xs">エラー</span>
  163. <button
  164. @click.stop="retryLoadThumbnail(index)"
  165. class="px-2 py-1 bg-blue-500 text-white text-xs rounded hover:bg-blue-600 transition-colors duration-300"
  166. >
  167. 再試行
  168. </button>
  169. </div>
  170. </div>
  171. </div>
  172. </div>
  173. </div>
  174. <!-- 右侧产品信息 -->
  175. <div class="flex flex-col gap-8">
  176. <!-- 产品名称 -->
  177. <div class="bg-zinc-900 rounded-lg p-6">
  178. <h1 class="text-white text-3xl font-medium mb-4">
  179. {{ product.title || product.name }}
  180. </h1>
  181. <div class="text-stone-400 text-lg leading-relaxed">
  182. {{ product.summary }}
  183. </div>
  184. </div>
  185. <!-- 产品参数 -->
  186. <div class="bg-zinc-900 rounded-lg p-6">
  187. <h2 class="text-white text-xl font-medium mb-6">製品仕様</h2>
  188. <div class="grid grid-cols-1 gap-4">
  189. <div
  190. class="flex justify-between items-center py-2 border-b border-zinc-800"
  191. >
  192. <span class="text-stone-400">カテゴリー</span>
  193. <span class="text-white font-medium">{{
  194. product.category
  195. }}</span>
  196. </div>
  197. <div
  198. class="flex justify-between items-center py-2 border-b border-zinc-800"
  199. >
  200. <span class="text-stone-400">用途</span>
  201. <span class="text-white font-medium">{{
  202. product.usage?.join(", ")
  203. }}</span>
  204. </div>
  205. <div class="flex justify-between items-center py-2">
  206. <span class="text-stone-400">容量</span>
  207. <span class="text-white font-medium">{{
  208. product.capacities?.join(" / ")
  209. }}</span>
  210. </div>
  211. </div>
  212. </div>
  213. <!-- 产品描述 -->
  214. <div class="bg-zinc-900 rounded-lg p-6">
  215. <h2 class="text-white text-xl font-medium mb-6">产品描述</h2>
  216. <div
  217. class="text-stone-400 leading-relaxed space-y-4 prose prose-invert max-w-none"
  218. >
  219. {{ product.description }}
  220. </div>
  221. </div>
  222. <div class="bg-zinc-900 rounded-lg p-6">
  223. <h2 class="text-white text-xl font-medium mb-6">详细描述</h2>
  224. <div
  225. class="text-stone-400 leading-relaxed space-y-4 prose prose-invert max-w-none"
  226. >
  227. <ContentRenderer :value="product.content" />
  228. </div>
  229. </div>
  230. <!-- 相关产品 -->
  231. <div
  232. v-if="relatedProducts.length > 0"
  233. class="bg-zinc-900 rounded-lg p-6"
  234. >
  235. <h2 class="text-white text-xl font-medium mb-6">
  236. {{
  237. product.meta?.series && product.meta.series.length > 0
  238. ? "同シリーズ製品"
  239. : "関連製品"
  240. }}
  241. </h2>
  242. <div
  243. class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4"
  244. >
  245. <nuxt-link
  246. v-for="relatedProduct in relatedProducts"
  247. :key="relatedProduct.id"
  248. :to="`/products/${relatedProduct.id}`"
  249. class="group"
  250. >
  251. <div
  252. class="bg-zinc-800 rounded-lg p-4 transition-all duration-300 hover:bg-zinc-700"
  253. >
  254. <div
  255. class="aspect-square mb-4 overflow-hidden rounded-lg"
  256. >
  257. <img
  258. :src="relatedProduct.image"
  259. :alt="relatedProduct.title || relatedProduct.name"
  260. class="w-full h-full object-cover transition-transform duration-300 group-hover:scale-110"
  261. />
  262. </div>
  263. <h3
  264. class="text-white text-lg font-medium mb-2 line-clamp-2"
  265. >
  266. {{ relatedProduct.title || relatedProduct.name }}
  267. </h3>
  268. <p class="text-stone-400 text-sm line-clamp-2">
  269. {{ relatedProduct.summary }}
  270. </p>
  271. </div>
  272. </nuxt-link>
  273. </div>
  274. </div>
  275. </div>
  276. </div>
  277. </div>
  278. </div>
  279. </div>
  280. </ErrorBoundary>
  281. </div>
  282. </template>
  283. <script setup lang="ts">
  284. /**
  285. * 产品详情页面
  286. * 展示产品主图、参数和描述
  287. */
  288. import { useErrorHandler } from "~/composables/useErrorHandler";
  289. import { useRoute, useI18n, useAsyncData } from "#imports";
  290. import { queryCollection } from "#imports";
  291. import { ContentRenderer } from "#components";
  292. const { error, isLoading } = useErrorHandler();
  293. const route = useRoute();
  294. const { locale, t } = useI18n();
  295. const id = route.params.id as string;
  296. // 图片状态
  297. const currentImage = ref<string>("");
  298. const isImageLoading = ref(true);
  299. const isThumbnailLoading = ref<boolean[]>([]);
  300. const imageError = ref(false);
  301. const thumbnailErrors = ref<boolean[]>([]);
  302. const preloadImage = ref<string | null>(null);
  303. interface Product {
  304. id: string;
  305. name: string;
  306. usage: string[];
  307. capacities: string[];
  308. category: string;
  309. description: string;
  310. summary: string;
  311. image: string;
  312. gallery: string[];
  313. body: string;
  314. content?: any;
  315. meta?: {
  316. series?: string[];
  317. name?: string;
  318. title?: string;
  319. image?: string;
  320. summary?: string;
  321. };
  322. title?: string;
  323. }
  324. /**
  325. * 使用queryCollection获取产品数据
  326. */
  327. const { data: productContent } = await useAsyncData(`product-${id}`, async () => {
  328. try {
  329. // 使用queryCollection从content目录获取数据
  330. const content = await queryCollection("content")
  331. .where("path", "LIKE", `/products/${locale.value}/${id}`)
  332. .first();
  333. return content;
  334. } catch (err) {
  335. console.error("Error fetching product content:", err);
  336. error.value = new Error(t("products.loadError"));
  337. return null;
  338. }
  339. });
  340. /**
  341. * 获取分类信息
  342. */
  343. const { data: categoryContent } = await useAsyncData(
  344. `category-${productContent.value?.categoryId}`,
  345. async () => {
  346. if (!productContent.value?.categoryId) return null;
  347. try {
  348. const content = await queryCollection("content")
  349. .where("path", "LIKE", `/categories/${locale.value}/${productContent.value.categoryId}`)
  350. .first();
  351. return content;
  352. } catch (err) {
  353. console.error("Error fetching category:", err);
  354. return null;
  355. }
  356. },
  357. {
  358. immediate: !!productContent.value?.categoryId
  359. }
  360. );
  361. /**
  362. * 使用计算属性解析产品数据
  363. */
  364. const product = computed<Product | null>(() => {
  365. if (!productContent.value) return null;
  366. // 提取产品数据
  367. const meta = productContent.value.meta || {};
  368. return {
  369. id: id,
  370. name: String(meta.name || productContent.value.title || ""),
  371. title: String(productContent.value.title || meta.name || ""),
  372. usage: Array.isArray(meta.usage) ? meta.usage : [],
  373. capacities: Array.isArray(meta.capacities) ? meta.capacities : [],
  374. category: categoryContent.value?.title || "",
  375. description: productContent.value.description || "",
  376. summary: String(meta.summary || ""),
  377. image: String(meta.image || ""),
  378. gallery: Array.isArray(meta.gallery) ? meta.gallery : [],
  379. body: productContent.value.body || "",
  380. content: productContent.value,
  381. meta: {
  382. series: Array.isArray(meta.series) ? meta.series : [],
  383. name: String(meta.name || ""),
  384. title: String(productContent.value.title || ""),
  385. image: String(meta.image || ""),
  386. summary: String(meta.summary || ""),
  387. },
  388. };
  389. });
  390. /**
  391. * 获取相关产品
  392. */
  393. const { data: relatedProductsContent } = await useAsyncData(
  394. `related-products-${id}`,
  395. async () => {
  396. try {
  397. // 获取产品列表
  398. const content = await queryCollection("content")
  399. .where("path", "LIKE", `/products/${locale.value}/%`)
  400. .all();
  401. return content;
  402. } catch (err) {
  403. console.error("Error fetching related products:", err);
  404. return [];
  405. }
  406. }
  407. );
  408. /**
  409. * 处理相关产品数据
  410. */
  411. const relatedProducts = computed(() => {
  412. if (!relatedProductsContent.value || !product.value) return [];
  413. return relatedProductsContent.value
  414. .filter((item: any) => item._path !== `/products/${locale.value}/${id}`)
  415. .map((item: any) => {
  416. const meta = item.meta || {};
  417. return {
  418. id: item._path?.split('/').pop() || "",
  419. name: meta.name || item.title || "",
  420. title: item.title || meta.name || "",
  421. image: meta.image || "",
  422. summary: meta.summary || "",
  423. };
  424. })
  425. .slice(0, 6); // 最多显示6个相关产品
  426. });
  427. /**
  428. * 预加载下一张图片
  429. */
  430. function preloadNextImage(image: string) {
  431. preloadImage.value = image;
  432. }
  433. /**
  434. * 处理预加载完成
  435. */
  436. function handlePreloadComplete() {
  437. preloadImage.value = null;
  438. }
  439. /**
  440. * 处理图片加载完成
  441. */
  442. function handleImageLoad() {
  443. isImageLoading.value = false;
  444. imageError.value = false;
  445. }
  446. /**
  447. * 处理图片加载错误
  448. */
  449. function handleImageError() {
  450. isImageLoading.value = false;
  451. imageError.value = true;
  452. }
  453. /**
  454. * 重试加载图片
  455. */
  456. function retryLoadImage() {
  457. isImageLoading.value = true;
  458. imageError.value = false;
  459. // 强制重新加载图片
  460. const img = new Image();
  461. img.src = currentImage.value;
  462. img.onload = () => {
  463. handleImageLoad();
  464. };
  465. img.onerror = () => {
  466. handleImageError();
  467. };
  468. }
  469. /**
  470. * 重试加载缩略图
  471. */
  472. function retryLoadThumbnail(index: number) {
  473. isThumbnailLoading.value[index] = true;
  474. thumbnailErrors.value[index] = false;
  475. // 强制重新加载缩略图
  476. const img = new Image();
  477. const images = [product.value?.image, ...(product.value?.gallery || [])];
  478. img.src = images[index] || "";
  479. img.onload = () => {
  480. handleThumbnailLoad(index);
  481. };
  482. img.onerror = () => {
  483. handleThumbnailError(index);
  484. };
  485. }
  486. /**
  487. * 处理缩略图加载完成
  488. */
  489. function handleThumbnailLoad(index: number) {
  490. isThumbnailLoading.value[index] = false;
  491. thumbnailErrors.value[index] = false;
  492. }
  493. /**
  494. * 处理缩略图加载错误
  495. */
  496. function handleThumbnailError(index: number) {
  497. isThumbnailLoading.value[index] = false;
  498. thumbnailErrors.value[index] = true;
  499. }
  500. /**
  501. * 切换图片
  502. */
  503. function changeImage(image: string | undefined) {
  504. if (image && image !== currentImage.value) {
  505. isImageLoading.value = true;
  506. imageError.value = false;
  507. preloadNextImage(image);
  508. currentImage.value = image;
  509. }
  510. }
  511. // 页面加载时初始化状态
  512. onMounted(() => {
  513. // 设置当前图片
  514. if (product.value?.image) {
  515. currentImage.value = product.value.image;
  516. }
  517. // 初始化缩略图加载状态数组
  518. const galleryLength = (product.value?.gallery?.length || 0) + 1; // +1是因为主图也算一张
  519. isThumbnailLoading.value = Array(galleryLength).fill(true);
  520. thumbnailErrors.value = Array(galleryLength).fill(false);
  521. });
  522. // SEO优化
  523. useHead(() => ({
  524. title: `${product.value?.name || "产品详情"} - Hanye`,
  525. meta: [
  526. {
  527. name: "description",
  528. content: product.value?.description || "产品详情页面",
  529. },
  530. ],
  531. }));
  532. </script>
  533. <style scoped>
  534. /* 隐藏滚动条但保持滚动功能 */
  535. .scrollbar-hide {
  536. -ms-overflow-style: none; /* IE and Edge */
  537. scrollbar-width: none; /* Firefox */
  538. }
  539. .scrollbar-hide::-webkit-scrollbar {
  540. display: none; /* Chrome, Safari and Opera */
  541. }
  542. /* 图片过渡动画 */
  543. .main-image {
  544. transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
  545. }
  546. /* 缩略图悬停效果 */
  547. .thumbnail-item {
  548. transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
  549. }
  550. .thumbnail-item:hover {
  551. transform: translateY(-2px);
  552. }
  553. /* 缩略图选中效果 */
  554. .thumbnail-item.selected {
  555. transform: scale(1.05);
  556. box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1),
  557. 0 2px 4px -1px rgba(0, 0, 0, 0.06);
  558. }
  559. /* 产品信息卡片效果 */
  560. .info-card {
  561. transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
  562. }
  563. .info-card:hover {
  564. transform: translateY(-2px);
  565. box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1),
  566. 0 2px 4px -1px rgba(0, 0, 0, 0.06);
  567. }
  568. /* 添加 prose 样式 */
  569. .prose {
  570. @apply text-stone-400;
  571. }
  572. .prose h1,
  573. .prose h2,
  574. .prose h3,
  575. .prose h4,
  576. .prose h5,
  577. .prose h6 {
  578. @apply text-white font-medium;
  579. }
  580. .prose a {
  581. @apply text-blue-400 hover:text-blue-300;
  582. }
  583. .prose ul,
  584. .prose ol {
  585. @apply list-disc list-inside;
  586. }
  587. .prose blockquote {
  588. @apply border-l-4 border-zinc-700 pl-4 italic;
  589. }
  590. .prose code {
  591. @apply bg-zinc-800 px-1 py-0.5 rounded;
  592. }
  593. .prose pre {
  594. @apply bg-zinc-800 p-4 rounded-lg overflow-x-auto;
  595. }
  596. .prose img {
  597. @apply rounded-lg;
  598. }
  599. </style>